package nimbus
{
	import flash.display.DisplayObject;
	import flash.display.Sprite;
	import flash.display.Stage;
	import flash.events.Event;
	import flash.events.EventDispatcher;
	import flash.utils.Dictionary;
	import flash.utils.getQualifiedClassName;
	
	import mx.collections.ArrayCollection;
	import mx.core.Application;
	import mx.events.CollectionEvent;
	import mx.managers.PopUpManager;
	
	import nimbus.commands.AsyncCommand;
	import nimbus.commands.base.ICombinableCommand;
	import nimbus.commands.base.ICombinableOptionalCommand;
	import nimbus.commands.base.ICommand;
	import nimbus.commands.base.IExecutionOptionalCommand;
	import nimbus.commands.base.IUndoableCommand;
	import nimbus.events.ExecutionEvent;
	import nimbus.events.ProcessingMessageEvent;
	import nimbus.events.UndoRedoEvent;
	
	/**
	 * Dispatched each time the current processing message has changed due to the start of a command
	 * execution, a command completion, or both.  Be listening to this event you can display
	 * feedback to users regarding the current progress of processing actions.  This only dispatches
	 * message information and leaves the display of the message up to each application's specific
	 * implementation.
	 */
	[Event(name="processingMessageChanged", type="nimbus.events.ProcessingMessageEvent")]
	
	[Event(name="commandAdded", type="nimbus.events.UndoRedoEvent")]
	[Event(name="undoExecuted", type="nimbus.events.UndoRedoEvent")]
	[Event(name="redoExecuted", type="nimbus.events.UndoRedoEvent")]
	[Event(name="historyTrimmed", type="nimbus.events.UndoRedoEvent")]
	public class Controller extends EventDispatcher
	{
		/**
		 * Manages the state of undo/redo functionality.
		 */
		protected var undoRedoHistory:UndoRedoHistory;
		
		/**
		 * A holding ArrayCollection for any asyncronous commands currently being executed.
		 */
		protected var asyncCommandsInProgress:ArrayCollection;
		
		/**
		 * @private
		 */
		protected var _successCallbacksByCommand:Dictionary;
		
		/**
		 * A dictionary where the key is the command and the value is the success callback
		 * function.
		 */
		protected function get successCallbacksByCommand():Dictionary
		{
			if (!_successCallbacksByCommand)
			{
				_successCallbacksByCommand = new Dictionary();
			}
			return _successCallbacksByCommand;
		}
		
		/**
		 * @private
		 */
		protected var _failureCallbacksByCommand:Dictionary;
		
		/**
		 * A dictionary where the key is the command and the value is the failure callback
		 * function.
		 */
		protected function get failureCallbacksByCommand():Dictionary
		{
			if (!_failureCallbacksByCommand)
			{
				_failureCallbacksByCommand = new Dictionary();
			}
			return _failureCallbacksByCommand;
		}
		
		/**
		 * Simple sprite used to cover up the application's stage when an asynchronous command's
		 * blockUserInteraction is set to true and the command is executing.
		 */
		protected var interactionBlockingRect:Sprite;
		
		/**
		 * Whether an IUndoableCommand should be stored in the UndoRedoHistory when it is executed.
		 * This is handy if you need to execute a few commands that implement IUndoableCommand
		 * but that you don't want stored in the history.
		 * 
		 * @default true
		 */
		public var storingUndoableCommandsEnabled:Boolean = true;
		
		/**
		 * Whether an ICombinableCommand should be combined with a previous command if all the
		 * criteria are met.  This is handy if you need to execute a few commands that implement
		 * ICombinableCommand but you don't want them to be combined with previous commands.
		 * As an alternative, you could call resetCombinable() after each command, but this is a
		 * nice shortcut.
		 * 
		 * @default true
		 */
		public var combiningEnabled:Boolean = true;
		
		/**
		 * The command to be combined with future commands of the same type.  This will be nulled
		 * out once a command of a different type is executed or resetCombinable() is called.
		 * 
		 * @see #resetCombinable()
		 * @see #combindCommand()
		 */
		protected var combinableCommand:ICombinableCommand;
		
		[Bindable(event="commandAdded")]
		[Bindable(event="undoExecuted")]
		[Bindable(event="redoExecuted")]
		[Bindable(event="historyTrimmed")]
		/**
		 * The number of commands that are currently available for undo.
		 * 
		 * @see nimbus.UndoRedoHistory#undosAvailable
		 */
		public function get undosAvailable():uint
		{
			return undoRedoHistory.undosAvailable;
		}
		
		[Bindable(event="commandAdded")]
		[Bindable(event="undoExecuted")]
		[Bindable(event="redoExecuted")]
		[Bindable(event="historyTrimmed")]
		/**
		 * The number of commands that are currently available for redo.
		 * 
		 * @see nimbus.UndoRedoHistory#redosAvailable
		 */
		public function get redosAvailable():uint
		{
			return undoRedoHistory.redosAvailable;
		}
		
		/**
		 * The maximum number of commands that are held for undo.  For example, if 
		 * 10 commands are already avaiable for undo and another undoable command is then executed, 
		 * the oldest command is purged from the history and the new command is added.
		 * 
		 * @see nimbus.UndoRedoHistory#maxUndos
		 */
		public function get maxUndos():uint
		{
			return undoRedoHistory.maxUndos;
		}
		
		/**
		 * @private
		 */
		public function set maxUndos(value:uint):void
		{
			undoRedoHistory.maxUndos = value;
		}
		
		/**
		 * Constructor.
		 */
		public function Controller()
		{
			undoRedoHistory = new UndoRedoHistory();
			undoRedoHistory.addEventListener(UndoRedoEvent.COMMAND_ADDED, undoRedoHandler);
			undoRedoHistory.addEventListener(UndoRedoEvent.HISTORY_TRIMMED, undoRedoHandler);
			undoRedoHistory.addEventListener(UndoRedoEvent.REDO_EXECUTED, undoRedoHandler);
			undoRedoHistory.addEventListener(UndoRedoEvent.UNDO_EXECUTED, undoRedoHandler);
			
			asyncCommandsInProgress = new ArrayCollection();
			asyncCommandsInProgress.addEventListener(CollectionEvent.COLLECTION_CHANGE,
					deriveProcessingMessage);
			asyncCommandsInProgress.addEventListener(CollectionEvent.COLLECTION_CHANGE,
					deriveInteractionBlocking);
		}
		
		/**
		 * @private
		 */
		private var _processingMessage:String;
		
		/**
		 * The processing message associated with the command currently being executed.  To detect
		 * when the processing message changes, listen for
		 * ProcessingMessageEvent.PROCESSING_MESSAGE_CHANGED events.
		 * 
		 * @see nimbus.events.ProcessingMessageEvent
		 * @see nimbus.commands.AsyncCommand#processingMessage
		 */
		protected function get processingMessage():String
		{
			return _processingMessage;
		}
		
		/**
		 * @private
		 */
		protected function set processingMessage(value:String):void
		{
			if (_processingMessage != value)
			{
				_processingMessage = value;
				
				// Since the new processing message is different than the old one, we must notify
				// any listeners.
				dispatchEvent(new ProcessingMessageEvent(
						ProcessingMessageEvent.PROCESSING_MESSAGE_CHANGED,
						value));
			}
		}
		
		/**
		 * Executes a command.
		 * 
		 * @param type Once a command has been executed, an ExecutionEvent is dispatched with this
		 * string used as the event type.  By listening to events with the specified type,
		 * listeners can detect when the command has completed execution.
		 * @param commandClass The class of the command that should be executed.
		 * @param arguments An array of arguments that will be passed to the constructor of the 
		 * command.  Any application data that needs to be referenced inside the command should be
		 * passed through this parameter.
		 * @param successCallback An optional function that will be called if the command 
		 * successfully executes.
		 * @param failureCallback An optional function that will be called if the command 
		 * unsuccessfully executes.
		 * @param combineEnabled If true, combining takes place if all other combining conditions
		 * are met.  If false, combining will not take place for this particular command execution.
		 * @param undoEnabled If true, undo storage takes place if all other undoable conditions
		 * are met.  If false, the command will not be stored for undo for this particular command
		 * execution.
		 * 
		 * @see nimbus.commands.base.ICommand#execute()
		 * 
		 * @return The ICommand instance of the command that was executed.  Be careful when assuming
		 * the execution state of the command.  If the command is an AsyncCommand, but actually
		 * executes in a synchronous fashion, the command being returned from this function will
		 * have already executed and dispatched its execution event.
		 */ 
		protected function execCommand(type:String, commandClass:Class, arguments:Array=null,
				successCallback:Function=null, failureCallback:Function=null, 
				combineEnabled:Boolean=true, undoEnabled:Boolean=true):ICommand
		{
			var command:ICommand = ICommand(applyInstantiation(commandClass, arguments));
			command.type = type;
			
			// Store callback functions
			if (successCallback != null)
			{
				successCallbacksByCommand[command] = successCallback; 
			}
			
			if (failureCallback != null)
			{
				failureCallbacksByCommand[command] = failureCallback;
			}
			
			var commandExecuted:Boolean;
			if (!(command is IExecutionOptionalCommand) || 
					IExecutionOptionalCommand(command).shouldBeExecuted)
			{
				execInstantiatedCommand(command);
				commandExecuted = true;
			}
			else
			{
				commandComplete(command, true);
				commandExecuted = false;
			}
			
			var commandCombined:Boolean = false;
			
			if (combineEnabled && commandExecuted)
			{
				commandCombined = combineCommand(command);
			}
			
			if (undoEnabled)
			{
				// Add the command to undo/redo history if it is undoable and if it wasn't combined
				// with a previous command.
				if (storingUndoableCommandsEnabled && commandExecuted && !commandCombined && 
						command is IUndoableCommand)
				{
					undoRedoHistory.addCommand(IUndoableCommand(command));
				}
			}
			
			return command;
		}
		
		protected function execInstantiatedCommand(command:ICommand):void
		{
			// If it's an asyncronous command, we must watch for execution to complete.  Otherwise,
			// it's a synchronous command and will be executed immediately.
			if (command is AsyncCommand)
			{
				asyncCommandsInProgress.addItem(command);
				AsyncCommand(command).addEventListener(
						ExecutionEvent.EXECUTION_COMPLETE,
						asyncCommandComplete);
				AsyncCommand(command).addEventListener(
						ProcessingMessageEvent.PROCESSING_MESSAGE_CHANGED,
						deriveProcessingMessage);
				AsyncCommand(command).execute();
			}
			else
			{
				command.execute();
				commandComplete(command, true);
			}
		}
		
		/**
		 * Combine the command if it is a combinable command and the last combinable command
		 * executed is of the same class.  If the last combinable command executed was not
		 * of the same class, store the new command to be combined with future combinable
		 * commands of the same class.
		 * 
		 * @param command The command to combine with a previous command, if possible.
		 * @return Whether the command was combined with a previous command.
		 */		
		protected function combineCommand(command:ICommand):Boolean
		{
			if (canBeCombined(command))
			{
				combinableCommand.combine(ICombinableCommand(command));
				return true;
			}
			else if (canBeStoredForCombine(command))
			{
				combinableCommand = ICombinableCommand(command);
				return false;
			}
			else
			{
				resetCombinable();
				return false;
			}
		}
		
		/**
		 * Handles asynchronous command completion.  Cleans up listeners and references.
		 */
		protected function asyncCommandComplete(event:ExecutionEvent):void
		{
			var command:AsyncCommand = AsyncCommand(event.target);
			command.removeEventListener(
					ExecutionEvent.EXECUTION_COMPLETE, 
					asyncCommandComplete);
			command.removeEventListener(
					ProcessingMessageEvent.PROCESSING_MESSAGE_CHANGED,
					deriveProcessingMessage);
			asyncCommandsInProgress.removeItemAt(asyncCommandsInProgress.getItemIndex(command));
			commandComplete(command, event.success);
		}
		
		/**
		 * Handles command completion.  Dispatches an ExecutionEvent with the command's type as
		 * the event type.
		 * 
		 * @param executionEventOrSyncCommand An ExecutionEvent if the command was asynchronous
		 * or the actual command object if the command was synchronous.
		 */
		protected function commandComplete(command:ICommand, success:Boolean):void
		{
			var finish:Function = function():void
			{
				// Remove callback references.
				delete successCallbacksByCommand[command];
				delete failureCallbacksByCommand[command];
				dispatchEvent(new ExecutionEvent(command.type, command, success));
			}
			
			// The result and fault handlers have a chance of failing but we ALWAYS want finish()
			// to be called regardless so we'll use a "finally"
			try
			{
				// Execute callback functions if they exist.
				if (success)
				{
					var successCallback:Function = successCallbacksByCommand[command];
					if (successCallback != null)
					{
						successCallback(command);
					} 
				}
				else
				{
					var failureCallback:Function = failureCallbacksByCommand[command];
					if (failureCallback != null)
					{
						failureCallback(command);
					}
				}
			}
			finally
			{
				finish();
			}
		}
		
		/**
		 * Calls the undo function of the most recently executed undoable command. Unlike normal 
		 * execution, when an redo is called and the command is an AsynCommand, the Controller does 
		 * not wait for the command to dispatch an ExecutionEvent before it dispatches its own 
		 * ExecutionEvent. The controller also does not incorporate certain features
	 	 * such as user interaction blocking or processing message updating during an undo/redo.
	 	 * 
	 	 * @see nimbus.UndoRedoHistory#undo()
		 */
		public function undo():IUndoableCommand
		{
			var command:IUndoableCommand = undoRedoHistory.undo();
			
			if (command == combinableCommand)
				resetCombinable();
			
			if (command)
			{
				dispatchEvent(new ExecutionEvent(command.type, command, true, true, false));
			}
			
			return command;
		}
		
		/**
		 * Calls the redo function of the most recent undone command. Unlike normal execution, when 
		 * an redo is called and the command is an AsynCommand, the Controller does not wait for the 
		 * command to dispatch an ExecutionEvent before it dispatches its own ExecutionEvent.
		 * The controller also does not incorporate certain features such as user interaction 
		 * blocking or processing message updating during an undo/redo.
		 * 
		 * @see nimbus.UndoRedoHistory#redo()
		 */
		public function redo():IUndoableCommand
		{
			var command:IUndoableCommand = undoRedoHistory.redo();
			
			if (command)
			{
				dispatchEvent(new ExecutionEvent(command.type, command, true, false, true));
			}
			
			return command;
		}
		
		/**
		 * Clears all history (both undo and redo) of commands executed.
		 * 
		 * @see nimbus.UndoRedoHistory#clearHistory()
		 */
		public function clearUndoRedoHistory():void
		{
			undoRedoHistory.clearHistory();
		}
		
		/**
		 * @see nimbus.UndoRedoHistory#peekUndo()
		 */
		public function peekUndo():ICommand
		{
			return undoRedoHistory.peekUndo();
		}
		
		/**
		 * @see nimbus.UndoRedoHistory#peekRedo()
		 */
		public function peekRedo():ICommand
		{
			return undoRedoHistory.peekRedo();
		}
		
		/**
		 * @private
		 * Accepts UndoRedoEvents from the UndoRedoHistory manager and re-dispatches them.
		 * This provides developers with a single access point so they do not need a direct
		 * reference to UndoRedoHistory.
		 * 
		 * @param event An UndoRedoEvent instance.
		 */
		protected function undoRedoHandler(event:UndoRedoEvent):void
		{
			dispatchEvent(event);
		}
		
		/**
		 * Resets the reference to the last combinable command stored, allowing the next series of
		 * calls to the same combinable command to be added to the undo/redo history. 
		 * For example, if you wanted to have every 10 calls to a combinable command undoable,
		 * then you should call <code>resetCombinable()</code> after every 10 calls
		 * to the combinable command. 
		 */		
		public function resetCombinable():void
		{
			combinableCommand = null;
		}
		
		/**
		 * @param command The command to test.
		 * @return Whether the command can be combined with the previous command.
		 */
		public function canBeCombined(command:ICommand):Boolean
		{
			return canBeStoredForCombine(command) && combinableCommand && 
					combinableCommand is Object(command).constructor;
		}
		
		/**
		 * @param command The command to test.
		 * @return Whether the command can be stored for combining.
		 */
		public function canBeStoredForCombine(command:ICommand):Boolean
		{
			return combiningEnabled && command is ICombinableCommand && 
				(!(command is ICombinableOptionalCommand) || 
				ICombinableOptionalCommand(command).combineIfPossible);
		}
		
		/**
		 * Analyzes currently executing commands to determine which processing message should
		 * be used to provide feedback to the user.  The first processing message found in the
		 * list of processing commands will be used.  To watch for processing message changes,
		 * 
		 * @see #processingMessage
		 */
		protected function deriveProcessingMessage(event:Event):void
		{
			for each (var asyncCommand:AsyncCommand in asyncCommandsInProgress)
			{
				if (asyncCommand.processingMessage != null)
				{
					processingMessage = asyncCommand.processingMessage;
					return;
				}
			}
			
			processingMessage = null;
		}
		
		/**
		 * Analyzes currently executing commands to determine whether any indicate that user
		 * interaction should be blocked.  If any executing command is found with 
		 * blockUserInteraction set to true, a transparent, empty sprite is placed over the
		 * entire application to prevent the user from interacting with the user interface.
		 */
		protected function deriveInteractionBlocking(event:Event):void 
		{
			var blockInteraction:Boolean = false;
			
			for each (var asyncCommand:AsyncCommand in asyncCommandsInProgress)
			{
				if (asyncCommand.blockUserInteraction)
				{
					blockInteraction = true;
					break;
				}
			}
			
			if (blockInteraction)
			{
				addBlockingRect();
			}
			else
			{
				removeBlockingRect();
			}
		}
		
		protected function addBlockingRect():void
		{
			if (!interactionBlockingRect)
			{
				var stage:Stage = DisplayObject(Application.application).stage;
				if (stage)
				{
					interactionBlockingRect = new Sprite();
					interactionBlockingRect.graphics.beginFill(0xff0000, 0);
					interactionBlockingRect.graphics.drawRect(0,0,stage.width,stage.height);
					interactionBlockingRect.mouseEnabled = true;
					interactionBlockingRect.useHandCursor = false;
					stage.addChildAt(interactionBlockingRect, stage.numChildren-1);
				}
				else
				{
					trace('Cannot add interaction blocking because the swf root is inaccessible. ' +
						'Please ensure the controller is in the main application domain.');
				}
			}
		}
		
		protected function removeBlockingRect():void
		{
			if (interactionBlockingRect)
			{
				var stage:Stage = DisplayObject(Application.application).stage;
				
				if (stage)
				{
					if (stage.contains(interactionBlockingRect))
					{
						stage.removeChild(interactionBlockingRect);
					}
							
					interactionBlockingRect = null;
				}
				else
				{
					trace('Cannot add interaction blocking because the swf root is inaccessible. ' +
						'Please ensure the controller is in the main application domain.');
				}
			}
		}
		
		/**
		 * Creates an instance of a class and passes parameters into the constructor.
		 * This is currently a hack because AS doesn't allow for dynamically applying
		 * an array of parameters to a constructor.  If it were a normal function, we could have
		 * used Function.apply().
		 * 
		 * @param classObject The class that should be instantiated.
		 * @param params An array of variables that should be passed as parameters into the
		 * constructor of the instantiated class.
		 * 
		 * @return An instantiated ICommand object.
		 */
		public function applyInstantiation(classObject:Class, params:Array):ICommand
		{
			if (!params)
			{
				return new classObject();
			}
			
			switch(params.length)
			{
				case 0: return new classObject();
				case 1: return new classObject(params[0]);
				case 2: return new classObject(params[0], params[1]);
				case 3: return new classObject(params[0], params[1], params[2]);
				case 4: return new classObject(params[0], params[1], params[2], params[3]);
				case 5: return new classObject(params[0], params[1], params[2], params[3], params[4]);
				case 6: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5]);
				case 7: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6]);
				case 8: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7]);
				case 9: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8]);
				case 10: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9]);
				case 11: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9], params[10]);
				case 12: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9], params[10], params[11]);
				case 13: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9], params[10], params[11], params[12]);
				case 14: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9], params[10], params[11], params[12], params[13]);
				case 15: return new classObject(params[0], params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8], params[9], params[10], params[11], params[12], params[13], params[14]);
			}
			
			throw new Error('A maximum of 15 parameters are currently supported with Nimbus. ' + 
					'To add support for additional parameters, modify ' + 
					getQualifiedClassName(this) + '.');
		}
	}
}